Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

53 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

@relintio/agent

npm node license

The Relintio agent for Node.js.


An in-process agent that scores every request before your handlers see it. It synchronizes an encrypted policy from the control plane every ten seconds or so, keeps it on disk so a restart is not a cold start, and decides — allow, delay, challenge, decoy, block — without a network round trip on the request path. No proxy, no DNS change, no sidecar.

import express from 'express';
import { ultimateProtectorExpress } from '@relintio/agent/express';

const app = express();

app.use(ultimateProtectorExpress({
  licenseKey: process.env.UP_LICENSE_KEY,
  apiUrl: 'https://api.relintio.com/v1',
}));

app.get('/', (req, res) => res.send('ok'));
app.listen(3000);

Installation

npm install @relintio/agent

Node 18 or newer — the agent uses global fetch. Express is an optional peer dependency pinned at >=4.21.2; the agent does not bundle it, and the floor exists so a project cannot satisfy the peer range with an Express that still carries the vulnerable path-to-regexp.

Registration

Register before every other app.use. Middleware added after your router runs once the route handler has already answered, and the diff looks identical either way — this is the most common way an install ends up looking finished while protecting nothing.

The middleware is a thin wrapper: it constructs one UltimateProtectorNodeAgent and forwards each request to it, routing any thrown error to next() so a bug in the agent cannot take a route down.

Zero-code preload

If you would rather not have anything touch application source:

export UP_LICENSE_KEY='UP_LIVE_…'
export UP_API_URL='https://api.relintio.com/v1'
export NODE_OPTIONS='--require @relintio/agent/preload'

node server.js

The preload patches createServer on node:http and node:https and wraps the request listener. It is idempotent, it exits immediately when the key or the URL is missing, and UP_AGENT_DISABLE turns it off without unsetting anything else. Because a bare http request and response are not Express objects, it installs minimal status/type/send/redirect shims and removes them again before calling your listener, so it never shadows a real Express API. Any failure inside the agent runs your original listener.

UP_ONLY_PATHS, UP_EXCEPT_PATHS, UP_ONLY_REGEX and UP_SYNC_INTERVAL_SECONDS map to the options below.

Configuration

Option Type Default Meaning
licenseKey string Required; the constructor throws without it. Secret — see below.
apiUrl string Required. https://api.relintio.com/v1. Trailing slashes are stripped.
syncIntervalSeconds number 10 Target refresh cadence. Floored at 10; jitter and backoff apply on top.
onlyPaths string[] Protect only these. Exact (/checkout) or prefix (/product/*).
exceptPaths string[] Skip these. Checked before onlyPaths, so an exception always wins.
onlyRegex string Regex source, with or without /…/flags delimiters. An unparseable value protects everything rather than nothing.
enforceTlsMinVersion boolean true Block TLS below 1.2. Applies only when the socket is a TLS socket.

Clean traffic is sampled at 1%, and that is not configurable. ALLOW_SAMPLE_RATE is exported from node-agent.js and fixed at 0.01, matching UsageMeterService::ALLOW_SAMPLE_RATE on the platform and AgentPayloadService::LOG_ALLOW_SAMPLE_RATE in the compiled engine. The platform multiplies a reported ALLOW back up by that rate to estimate real traffic, so an install choosing its own rate reports a number the platform then corrects by the wrong constant — silently, on that customer's bill. allowSampleRate is still accepted as an option and ignored. Blocks, challenges, decoys and slows are never sampled: they are the security record and are counted at face value.

rateLimitPerMinute is still accepted and has no effect. The fixed-window limiter it configured was replaced by the token bucket below; the option stayed so existing call sites keep working, but a number passed there does not change anything.

The licence key is a secret. It signs outbound requests and mints passports, so anything holding it can forge both. Keep it in the environment, never in a repository, and never in code that reaches a browser — that is what publishable keys are for, and those belong to the React and Shopify SDKs, not this one.

What happens on a request

Path filters first, so an excluded route costs nothing. Then the honeypot check, then the challenge passport, and only then the policy — the passport short-circuit exists so a visitor who has already proved themselves is not re-scored on every page.

Once the policy is loaded the agent walks it in a fixed order: server-supplied bypass_paths, the IP whitelist, SEO safety, the global blocklist, TLS fingerprint, geo firewall, blocked CIDRs, honeypot headers, scanner signatures and bot regex, VPN reverse DNS, referrer rules, per-licence WAF rules, and finally the score.

The order matters more than any individual rule. Cheap checks come first, anything that allows outright comes before anything that blocks, and the two checks that can touch the network — reverse DNS and geo enrichment — sit late enough that most requests never reach them.

curl_safety_paths from the policy marks a path as machine-facing: scanner signatures, bot regex and scoring are skipped there, while blocklists, geo and CIDR rules still apply. It is the narrow version of a bypass, and usually what an API route actually wants.

Scoring

Signals are additive and independent; the total is clamped to 0–100.

Signal Weight Fires when
ua_empty +50 No User-Agent at all
rate_burst +35 Token bucket exhausted
ua_too_short +25 User-Agent under 10 characters
no_accept_language +20 No Accept-Language
generic_accept +15 Accept missing or exactly */*
post_no_referer +15 POST with no Referer
scanner_keyword +15 A policy scanner keyword appears in the UA — counted once
conn_close +10 Connection: close
Tier Score Response
ALLOW 0–39 Passes through
SLOW 40–59 Two-second delay, then passes through
CHALLENGE 60–74 Redirect to the hosted challenge
DECOY 75–84 200 with a maintenance page, or the policy's cloak_html
BLOCK 85–100 403, or 200 with cloak_html

No single signal reaches BLOCK on its own. An empty user agent together with a missing Accept-Language and a generic Accept does, which is why a naive script is stopped and a merely unusual browser is not. These thresholds are fixed in this agent — the sensitivity setting that lowers them for the PHP agent is not read here.

Rate limiting

A per-IP token bucket: 8 tokens per second, 24 in the burst, refilled continuously rather than reset on a boundary. Exhausting it contributes +35 to the score; it does not block on its own.

Route multipliers scale both the refill rate and the ceiling, so the limit follows what the route is for:

Route Multiplier
/assets/ 2.0
/api/ 0.7
/wp-admin 0.5
/login, /auth, /wp-login 0.4

Buckets live in process memory and stale entries are swept every five minutes. Across a cluster each worker keeps its own, so the effective ceiling is the multiplier times the worker count.

Passport v2

A visitor who passes the challenge returns with ?up_token=<v2 token>. The agent verifies it, mints its own passport, sets it as the relintio_passport cookie (HttpOnly, SameSite=Lax, Secure over HTTPS) and redirects to the clean URL.

A token is v2.<payload>.<signature>: base64url JSON carrying an absolute expiry and a binding hash, signed with HMAC-SHA256 under the licence key. Verification is offline — no call back to the control plane, because the edge has to keep working when the control plane does not. Both the signature and the binding are compared in constant time.

The binding is sha256(licenceKey|userAgent|acceptLanguage), truncated to 16 hex characters, over the raw header values. The agent caps the user agent at 1024 characters for logging and scoring; the binding deliberately uses the uncapped one, because the server hashes what it received and a truncated copy would disagree on every visitor with a long UA.

The predecessor was sha256('verified' + licenceKey) — one constant string, identical for every visitor of a site, valid for a week. One leaked cookie bypassed the agent entirely until the key was rotated. Tokens in that form are no longer accepted, so any still in the wild simply challenge again.

An invalid up_token is a 403, not a pass-through. The only way to hold one is to have just passed the challenge, so a bad one is a forgery attempt rather than an accident.

Request signing

Every outbound ingest call carries:

X-Relintio-Timestamp: 1785120000
X-Relintio-Nonce:     <16–128 chars of [A-Za-z0-9_-]>
X-Relintio-Signature: v1=<64 hex>

The signature is HMAC-SHA256("v1:" + timestamp + ":" + nonce + ":" + sha256(body), licenceKey). The server checks the timestamp within ±300 seconds, the nonce unused within 600 seconds per credential, and the signature in constant time — and burns the nonce last, so a forged request cannot consume one the real agent is about to use.

The server's agent_signature_mode has three settings. off checks nothing. optional accepts an absent signature but still rejects a bad one, so corrupting the header cannot be used as a downgrade. required rejects unsigned ingest with 401, and is both the default and the steady state.

Every call goes through one private #postJson, which serialises the body once and signs that exact string. This is the part that is easy to get wrong: signing an object and letting fetch serialise it again puts a signature over bytes that were never transmitted, and the server — which hashes what it received — rejects everything, with nothing in any log to explain it. An endpoint added around the chokepoint rather than through it does not degrade gracefully; it 401s, and the edge goes blind. test/signing.test.js catches exactly that by intercepting a real request on a loopback socket and recomputing the signature from the bytes that arrived.

One call is outside the chokepoint. The geo fallback in GeoLookupCache posts to /agent/geo-lookup with its own fetch rather than through #postJson, and calls signingHeaders directly to sign it. It is signed, but it is a second call site that has to be kept in step with the first. Its catch turns any failure into XX, so if the two ever drift the symptom is geo rules quietly not matching for visitors whose CDN supplies no country header, with no error anywhere.

Challenge disabled

challenge_enabled is a policy setting and it is also plan-gated: a licence without the bot challenge has it forced off server-side. Being over a monthly allowance used to do the same, and no longer does — overage warns and bills, and never takes a defence away.

Rather than have each agent read the flag, /agent/challenge/init refuses to issue a token when it is off and answers 200 with {"status": "challenge_disabled", "fallback": "allow"|"block"}. The 200 is deliberate — this is a policy answer, not an outage, and an agent that treated it as a failure would fail closed on a setting the customer turned off on purpose.

This agent acts on it: allow calls next(), block serves the block page. The responder owns both paths, because every call site is return this.#respondChallenge(…) and a branch that returned without either responding or continuing would leave the request hanging until the client gave up.

Honeypot

The invisible link injected into HTML points at /.well-known/relintio-trap, and anything requesting it is blocked ahead of every other check. /.well-known/aura-trap — a leftover from the product's previous name — is still matched for one release but no longer planted, so crawlers already in flight against the old path are still caught while new ones only ever see the current one.

Policy sync and failure

Rules arrive AES-256-CBC encrypted with an HMAC, both keyed off the licence key, and are cached to the OS temp directory with an HMAC sidecar. A cache whose MAC does not verify, or one with no sidecar at all, is deleted and refetched rather than trusted — shared hosting is a real deployment target, and a poisoned rules file is a policy bypass.

Sync failures back off exponentially to a five-minute ceiling, with jitter on every interval so a fleet restarting together does not synchronize into a thundering herd. Intel lists the server marks unchanged are merged from the previous copy; a bumped rules_version discards them instead and forces a full download, which is how a dashboard cache purge reaches every agent.

While no policy has ever loaded, and whenever a fetch throws, the agent calls next(). A control-plane outage must never take a site down.

Edge cases

An inactive licence fails closed. When /agent/verify answers expired or outdated, the agent records the state and serves a 503 notice page to every request — including after a restart, since the state is cached to disk. That is the opposite of the outage behaviour above, and worth knowing before a subscription lapses.

Those two are the whole list. Every other answer — an unknown status, an empty body, a payload that will not decrypt — is treated as a failed sync: the cached policy stays in force, on disk and in memory, and the agent retries with backoff. quota_exceeded used to be a third state, and on it the agent dropped its ruleset and stopped protecting the site over a billing number; it is gone from the platform, and overage now warns and bills.

Machine callers score as bots. curl, python-requests and Go's default client send no Accept-Language and a generic Accept, which is 35 points before anything else. Exclude health checks and webhooks explicitly with exceptPaths or dashboard bypass rules — never by lowering protection globally, and never by carving out login, registration, checkout or password reset.

Body injection is best effort. When the policy enables Obsidian, the agent wraps res.send and res.end to splice its payload into HTML. Already-compressed responses are left alone, and anything writing to the socket directly is out of reach.

SEO safety off is strict. With seo_safety_enabled false, a request whose UA claims to be a crawler is refused either way: forward-confirmed reverse DNS earns a soft block that does not ban the IP, and a failed check earns a hard block that does. Turning it off does not mean "score crawlers normally"; it means "no crawlers".

In production

Start in observe mode, watch a day of real traffic, and only then enforce. The dashboard shows what the agent scored and why, so the question to settle before enforcement is whether the traffic you expect is scored the way you expect.

Run at least one deploy with agent_signature_mode at optional and the adoption page open. It records which credentials are signing and which are not, and that is the only way to know whether flipping to required will take part of the fleet dark.

Links

Security reports go to support@relintio.com, not to a public issue.

License

Proprietary. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages